HillChart.renderMainCurve   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 19
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 15
dl 0
loc 19
rs 9.65
c 0
b 0
f 0
1
import EventEmitter from 'event-emitter-es6';
2
import { Selection } from 'd3-selection';
3
4
import {
5
  event,
6
  select,
7
  scaleLinear,
8
  axisBottom,
9
  line,
10
  drag,
11
  range,
12
} from './d3';
13
import {
14
  hillFn,
15
  hillFnInverse,
16
  textOutRange,
17
  calculateTextPositionForX,
18
  calculateTextMarginForY,
19
  uId,
20
} from './helpers';
21
import './styles.css';
22
import { Config, Data, DataPointInternal, IHillChartClass } from './types';
23
import { DEFAULT_CONFIG, DEFAULT_SIZE } from './defaults';
24
25
export default class HillChart extends EventEmitter implements IHillChartClass {
26
  data: IHillChartClass['data'] = [];
27
28
  target = DEFAULT_CONFIG.target;
29
30
  width = DEFAULT_CONFIG.width;
31
32
  height = DEFAULT_CONFIG.height;
33
34
  preview = DEFAULT_CONFIG.preview;
35
36
  darkMode = DEFAULT_CONFIG.darkMode;
37
38
  backgroundColor = DEFAULT_CONFIG.backgroundColor;
39
40
  footerText = DEFAULT_CONFIG.footerText;
41
42
  margin = DEFAULT_CONFIG.margin;
43
44
  chartWidth = 0;
45
46
  chartHeight = 0;
47
48
  colorScheme: IHillChartClass['colorScheme'] = 'hill-chart-light';
49
50
  svg: IHillChartClass['svg'] = select<SVGGElement, DataPointInternal>('svg');
51
52
  xScale: IHillChartClass['xScale'] = scaleLinear();
53
54
  yScale: IHillChartClass['yScale'] = scaleLinear();
55
56
  bottomLine: IHillChartClass['bottomLine'] = axisBottom(this.xScale);
57
58
  mainLineCurvePoints: IHillChartClass['mainLineCurvePoints'] = [];
59
60
  line: IHillChartClass['line'] = line<Pick<DataPointInternal, 'x' | 'y'>>()
61
    .x(0)
62
    .y(0);
63
64
  constructor(data: Data, config?: Config) {
65
    super();
66
    Object.assign(this, DEFAULT_CONFIG, { data }, config);
67
    this.init();
68
  }
69
70
  init() {
71
    const { width, height, margin, target } = this;
72
73
    // Calculate real chart dimensions without the margins
74
    this.chartWidth = width - margin.left - margin.right;
75
    this.chartHeight = height - margin.top - margin.bottom;
76
77
    // Render the svg and center chart according to margins
78
    this.colorScheme = this.darkMode ? 'hill-chart-dark' : 'hill-chart-light';
79
    const defaultBg = this.darkMode ? '#2f3437' : '#ffffff';
80
    const bgColor = this.backgroundColor;
81
    const useDefaultBg = bgColor === true || bgColor === undefined;
82
    const useTransparentBg = this.backgroundColor === false;
83
    const suppliedBgColor = useDefaultBg ? defaultBg : this.backgroundColor;
84
    this.backgroundColor = useTransparentBg ? 'transparent' : suppliedBgColor;
85
86
    this.svg = select<SVGGElement, DataPointInternal>(target)
87
      .attr('class', this.colorScheme)
88
      .attr('width', width)
89
      .attr('height', height)
90
      .attr(
91
        'style',
92
        `stroke-width: 0; background-color: ${this.backgroundColor};`
93
      )
94
      .append('g')
95
      .attr('transform', `translate(${margin.left}, ${margin.top})`);
96
97
    // Set X and Y axis scale values, it used to determine the center of the chart
98
    // when calling this.xScale(50), it also flip the y axis to start from the
99
    // lowest point and scale up like claiming a hill from the ground.
100
    this.xScale = scaleLinear().domain([0, 100]).range([0, this.chartWidth]);
101
    this.yScale = scaleLinear().domain([0, 100]).range([this.chartHeight, 0]);
102
103
    // Normalize data on the y axis
104
    this.normalizeData();
105
  }
106
107
  normalizeData() {
108
    this.data = this.data.map((point) => {
109
      return {
110
        id: point.id ? point.id : uId(),
111
        color: point.color,
112
        description: point.description,
113
        link: point.link,
114
        x: point.x ? point.x : 0,
115
        y: point.y ? point.y : hillFn(point.x ? point.x : 0),
116
        size: point.size ? point.size : DEFAULT_SIZE,
117
      };
118
    });
119
  }
120
121
  // Replace the data points
122
  replaceData(data: Partial<DataPointInternal>[]) {
123
    // Update and normalize the data
124
    Object.assign(this, { data });
125
    this.normalizeData();
126
  }
127
128
  // Replace the data points, and re-render the group
129
  replaceAndUpdate(data: Partial<DataPointInternal>[]) {
130
    // Update and normalize the data
131
    this.replaceData(data);
132
133
    // Remove the existing points
134
    this.svg.selectAll('.hill-chart-group').remove();
135
136
    // Render group of points
137
    this.renderGroup();
138
  }
139
140
  undraggablePoint() {
141
    return this.svg
142
      .selectAll('.hill-chart-group')
143
      .data(this.data)
144
      .enter()
145
      .append('a')
146
      .attr('href', (data) => {
147
        return data.link ? data.link : '#';
148
      })
149
      .append('g')
150
      .attr('class', 'hill-chart-group')
151
      .style('cursor', 'pointer')
152
      .attr('transform', (data) => {
153
        data.x = this.xScale(data.x);
154
        data.y = this.yScale(data.y);
155
        return `translate(${data.x}, ${data.y})`;
156
      });
157
  }
158
159
  render() {
160
    // Render the horizontal bottom line on X axis
161
    this.renderBottomLine(5);
162
163
    // Render the main curve line
164
    this.renderMainCurve();
165
166
    // Render the line in the middle
167
    this.renderMiddleLine();
168
169
    if (this.footerText.show) {
170
      // Render the text on the footer
171
      this.renderFooterText();
172
    }
173
174
    // Render the group on the chart
175
    this.renderGroup();
176
  }
177
178
  renderGroup() {
179
    const self = this;
180
181
    // Handle dragging
182
    const dragPoint = drag<SVGGElement, DataPointInternal>()
183
      .on('drag', function (data) {
184
        let { x } = event;
185
186
        // Check point movement, preventing it from wondering outside the main curve
187
        if (!x || x < 0) {
188
          x = 0;
189
          self.emit('home', {
190
            ...data,
191
            y: hillFnInverse(self.yScale.invert(data.y)),
192
          });
193
        } else if (x > self.chartWidth) {
194
          x = self.chartWidth;
195
          self.emit('end', {
196
            ...data,
197
            x: self.xScale.invert(self.chartWidth),
198
            y: hillFnInverse(self.yScale.invert(data.y)),
199
          });
200
        }
201
202
        // Convert current point coordinates back to the original
203
        // between 0 and 100 to set it in the data attribute
204
        const invertedX = self.xScale.invert(x);
205
206
        data.x = x;
207
208
        data.y = self.yScale(hillFn(invertedX));
209
210
        const invertedY = hillFnInverse(self.yScale.invert(data.y));
211
212
        const newInvertedCoordinates = {
213
          x: invertedX,
214
          y: invertedY,
215
        };
216
217
        // click event
218
        select<SVGGElement, DataPointInternal>(this).on('click', () => {
219
          self.emit('pointClick', { ...data, ...newInvertedCoordinates });
220
        });
221
222
        if (!self.preview) {
223
          const selectedPoint = select<SVGGElement, DataPointInternal>(
224
            this
225
          ).attr('transform', `translate(${data.x}, ${data.y})`);
226
          selectedPoint
227
            .select('text')
228
            .style('text-anchor', () => {
229
              if (textOutRange(invertedX)) {
230
                return 'end';
231
              }
232
              return 'start';
233
            })
234
            .attr('x', (point) =>
235
              calculateTextPositionForX(point.size, invertedX)
236
            );
237
238
          self.emit('move', invertedX, invertedY);
239
        }
240
      })
241
      .on('end', (data) => {
242
        if (this.preview) {
243
          return;
244
        }
245
246
        let { x } = event;
247
248
        // Check point movement, preventing it from wondering outside the main curve
249
        if (!x || x < 0) {
250
          x = 0;
251
        } else if (x > this.chartWidth) {
252
          x = this.chartWidth;
253
        }
254
255
        // Convert current point coordinates back to the original
256
        const invertedX = this.xScale.invert(x);
257
        data.y = this.yScale(hillFn(invertedX));
258
        const invertedY = hillFnInverse(this.yScale.invert(data.y));
259
260
        const newInvertedCoordinates = {
261
          x: invertedX,
262
          y: invertedY,
263
        };
264
265
        this.emit('moved', { ...data, ...newInvertedCoordinates });
266
      });
267
268
    let group:
269
      | Selection<SVGGElement, DataPointInternal, SVGGElement, unknown>
270
      | undefined;
271
272
    if (this.preview) {
273
      group = this.undraggablePoint();
274
    } else {
275
      // Create group consisted of a circle and a description text, where
276
      // the data attributes determine the position of them on the curve
277
      group = this.svg
278
        .selectAll('.hill-chart-group')
279
        .data(this.data)
280
        .enter()
281
        .append('g')
282
        .attr('class', 'hill-chart-group')
283
        .attr('transform', (data) => {
284
          data.x = this.xScale(data.x);
285
          data.y = this.yScale(data.y);
286
          return `translate(${data.x}, ${data.y})`;
287
        })
288
        .call(dragPoint);
289
    }
290
291
    group
292
      .append('circle')
293
      .attr('class', 'hill-chart-circle')
294
      .attr('fill', (data) => data.color)
295
      .attr('cx', 0)
296
      .attr('cy', 0)
297
      .attr('r', (data) => data.size || DEFAULT_SIZE);
298
299
    group
300
      .append('text')
301
      .text((data) => data.description)
302
      .attr('x', (data) =>
303
        calculateTextPositionForX(
304
          data.size || DEFAULT_SIZE,
305
          this.xScale.invert(data.x)
306
        )
307
      )
308
      .style('text-anchor', (data) => {
309
        if (textOutRange(this.xScale.invert(data.x))) {
310
          return 'end';
311
        }
312
        return 'start';
313
      })
314
      .attr('y', calculateTextMarginForY());
315
  }
316
317
  renderMainCurve() {
318
    // Generate the main line curve points
319
    this.mainLineCurvePoints = range(0, 100, 0.1).map((i) => ({
320
      x: i,
321
      y: hillFn(i),
322
    }));
323
324
    // Map main line curve points to <svg> d attribute
325
    this.line = line<Pick<DataPointInternal, 'x' | 'y'>>()
326
      .x((d) => this.xScale(d.x))
327
      .y((d) => this.yScale(d.y));
328
329
    // Render the actual main line curve
330
    this.svg
331
      .append('path')
332
      .attr('class', 'chart-hill-main-curve')
333
      .datum(this.mainLineCurvePoints)
334
      .attr('d', this.line);
335
  }
336
337
  renderBottomLine(marginTop = 5) {
338
    // Generate the horizontal bottom line on the X axis
339
    this.bottomLine = axisBottom(this.xScale).ticks(0).tickSize(0);
340
341
    // Render the acutal svg
342
    this.svg
343
      .append('g')
344
      .attr('class', 'hill-chart-bottom-line')
345
      .attr('transform', `translate(0, ${this.chartHeight + marginTop})`)
346
      .call(this.bottomLine);
347
  }
348
349
  renderMiddleLine() {
350
    this.svg
351
      .append('line')
352
      .attr('class', 'hill-chart-middle-line')
353
      .attr('y1', this.yScale(0))
354
      .attr('y2', this.yScale(100))
355
      .attr('x2', this.xScale(50))
356
      .attr('x1', this.xScale(50));
357
  }
358
359
  renderFooterText() {
360
    this.svg
361
      .append('text')
362
      .attr('class', 'hill-chart-text')
363
      .text('Figuring things out')
364
      .style('font-size', `${this.footerText.fontSize}rem`)
365
      .attr('x', this.xScale(25))
366
      .attr('y', this.chartHeight + 30);
367
368
    this.svg
369
      .append('text')
370
      .attr('class', 'hill-chart-text')
371
      .text('Making it happen')
372
      .style('font-size', `${this.footerText.fontSize}rem`)
373
      .attr('x', this.xScale(75))
374
      .attr('y', this.chartHeight + 30);
375
  }
376
}
377